Skip to content

Stamp per-worker-process execution metadata on test results - #420

Merged
bitwise-aiden merged 2 commits into
mainfrom
parallel-worker-metadata
Aug 12, 2026
Merged

Stamp per-worker-process execution metadata on test results#420
bitwise-aiden merged 2 commits into
mainfrom
parallel-worker-metadata

Conversation

@bitwise-aiden

Copy link
Copy Markdown
Contributor

What

Stamps three per-worker-process execution metadata fields on every minitest result, carried nil-safely through TestData#to_h into log/test_data.json:

  • parallel_worker_pidProcess.pid at result creation.
  • parallel_worker_test_index — 0-based per-process monotonic counter, incremented per execution (requeued runs get their own index). Fork-safe: restarts when the pid changes, so each process incarnation gets a clean 0,1,2,… sequence.
  • parallel_worker_id — identifier injected by the embedding environment (e.g. a Rails parallel-testing worker number) via Minitest::Queue.parallel_worker_id= or CI_QUEUE_PARALLEL_WORKER_ID; nil when not applicable.

This makes per-worker-process execution order reconstructable downstream:

PARTITION BY job_id, parallel_worker_id, parallel_worker_pid
ORDER BY parallel_worker_test_index

i.e. a SQL query can reproduce any worker's test_order-w{N}-{pid}.log from warehouse rows — the prerequisite for warehouse-native test-pollution / requeue-overlap analysis.

How

  • New Minitest::ParallelWorkerMetadata accessors module prepended to Minitest::Result (fields ride the result object through Marshal/DRb).
  • Stamping happens at the top of Minitest::Queue.handle_test_result — in the process that ran the test for all in-process flows.
  • First-writer-wins: embedders that run tests in forked workers and transport results to a central reporting process (e.g. Rails parallelization over DRb, where handle_test_result runs server-side) must call Minitest::Queue.stamp_parallel_worker_metadata(result) in the worker before sending. Pre-stamped results pass through reporting untouched — otherwise the reporting-side stamp would carry the server's pid and an arrival-order index interleaved across workers.

Impact on existing consumers

  • Fields are additive and nil-safe; TestDataReporter is structurally unchanged.
  • rspec-queue, junit.xml, test_order.log, and Redis build-status/error reports are untouched.
  • Downstream Monorail wrappers whitelist payload keys, so the new keys are inert until schema/field lists are updated deliberately.

Testing

  • New unit suite for stamping (setter/env precedence, fork reset, first-writer-wins, nil-safety).
  • TestData unit tests for stamped/unstamped/accessor-less results.
  • Integration assertions in test_test_data_reporter: worker id from env, single pid, indexes exactly 0..N-1, requeued execution ordered before its final run.

Also bumps the version to 0.98.0 for release.

Adds three fields to each recorded result, stamped in the process that
ran the test (worker-side, before any DRb send in embedding
environments):

- parallel_worker_pid: Process.pid at result creation
- parallel_worker_test_index: 0-based per-process monotonic counter,
  incremented per execution (requeued runs get their own index), fork-safe
- parallel_worker_id: injected by the embedding environment via
  Minitest::Queue.parallel_worker_id= or CI_QUEUE_PARALLEL_WORKER_ID;
  nil when not applicable

Stamping is first-writer-wins: embedders that run tests in forked
workers and transport results to a central reporting process (e.g.
Rails parallel testing over DRb, where handle_test_result runs
server-side) must call Minitest::Queue.stamp_parallel_worker_metadata
in the worker before sending; pre-stamped results pass through
reporting untouched. Otherwise the reporting-side stamp would carry
the server's pid and an arrival-order index interleaved across workers.

The fields are carried nil-safely through TestData#to_h into
log/test_data.json (TestDataReporter unchanged), so per-worker-process
execution order is reconstructable downstream:

  PARTITION BY parallel_worker_id, parallel_worker_pid
  ORDER BY parallel_worker_test_index

Assisted-By: devx/77f6d45d-84ba-4c9e-983d-5ec948229a08
@bitwise-aiden
bitwise-aiden requested review from a team and nikita8 and removed request for a team August 10, 2026 19:15
@mdwn

mdwn commented Aug 11, 2026

Copy link
Copy Markdown

From River:

Reviewed at 3384eab. Nice change overall — the fork-reset mirrors the existing FileLoader#detect_fork! pattern, the accessors are nil-safe for embedders that don't have them, the new keys are purely additive, and the first-writer-wins contract has real test coverage. A few things I'd want addressed before this lands.

Blocking-ish

1. Thread-based parallelization silently produces wrong data

All three pieces of state — @parallel_worker_next_test_index, the pid, and parallel_worker_id — live on the Minitest::Queue singleton, so they are process-global.

Under thread-based parallelization (Rails parallelize(workers: N, with: :threads)) every "worker" shares one pid and one parallel_worker_id. The documented partition key:

PARTITION BY job_id, parallel_worker_id, parallel_worker_pid
ORDER BY parallel_worker_test_index

then collapses to a single partition, and the indexes interleave across threads. The result still looks like a valid per-worker execution order downstream, which is the worst failure mode for a telemetry field — silently wrong beats obviously missing. @parallel_worker_next_test_index += 1 also isn't atomic once you're off CRuby's GVL (JRuby/TruffleRuby, where :threads mode is most likely to be used).

Two ways out, either is fine:

  • Key the counter (and ideally the worker id) on Thread.current rather than the module.
  • Or state plainly in the README that only fork-based parallelization is supported, and that parallel_worker_* must not be trusted under with: :threads.

2. The version bump doesn't belong in this PR

Commit 3384eab bumps to 0.98.0. The repo's own Releasing a New Version section says to bump after merging changes to main, and the history backs that up — #419 was a standalone Bump ci-queue to v0.97.0. Bundling it here also guarantees a conflict with any other in-flight PR that does the same. Suggest dropping the commit and doing the release separately.

Worth fixing

3. ENV is re-read and re-parsed on every test

parallel_worker_id calls parallel_worker_id_from_env on every stamp, i.e. once per test execution. With a non-numeric value (CI_QUEUE_PARALLEL_WORKER_ID=worker-a, explicitly supported per the tests) that's an Integer() raise plus rescue per test. This repo has previously shaved per-heartbeat Symbol#to_s allocations, so a per-test exception is going to get noticed.

Memoize it, and clear the memo in the same pid-change branch that already resets the counter — that keeps it correct across forks, where a worker id injected post-fork must not be shadowed by the parent's value:

def parallel_worker_id
  return @parallel_worker_id if @parallel_worker_id

  @parallel_worker_id_from_env = parallel_worker_id_from_env unless defined?(@parallel_worker_id_from_env)
  @parallel_worker_id_from_env
end

4. The payload isn't self-sufficient for the query that justifies it

The partition key in the description starts with job_id, but test_data.json carries neither job_id nor ci-queue's own worker_idTestData#to_h has no worker or build identity at all. So "a SQL query can reproduce any worker's test_order-w{N}-{pid}.log" holds only if the ingestion layer attaches job_id out of band. Worth either emitting queue.config.worker_id alongside the new fields, or saying explicitly in the README where job_id is expected to come from.

5. Naming collides with ci-queue's existing worker_id

ci-queue already has a first-class worker_id: config.worker_id, set by --worker / inferred from the CI provider, used throughout CI::Queue::Redis::Worker, and already emitted in the worker profile payload. That's a whole queue worker. The new parallel_worker_id is a fork inside one of those. Two different "worker" concepts landing in the same telemetry pipeline will get conflated by whoever writes the queries. Not a blocker, but consider something like intra_worker_* / process_worker_*, or at minimum call out the relationship in the README.

Nits

  • README placement. The new ### Parallel worker metadata section sits right after the deprecated RSpec section, but the feature is minitest-only (rspec-queue is untouched). It belongs under ### Minitest, and the one-row env table would be better folded into the existing CI_QUEUE_* table there rather than starting a second one.
  • README accuracy. "the pid of the worker process at result creation" — it's actually stamped at the top of handle_test_result, after the test has run. Same process in the in-process flow, so the value is right, but the wording matters precisely because the DRb contract hinges on when stamping happens.
  • test_ignores_results_without_accessors asserts nothing. The comment says "does not raise", but nothing enforces that intent. assert_nil Minitest::Queue.stamp_parallel_worker_metadata(plain) (or assert_silent) would make the test fail for the right reason.
  • Unit tests leak global state. teardown restores @parallel_worker_id and the env var, but not @parallel_worker_metadata_pid / @parallel_worker_next_test_index — and test_index_restarts_when_pid_changes deliberately mutates the former. Harmless today because every other assertion is relative, but it's a trap for the next test added to this file.
  • assert_equal (0...failures.size).to_a, ... in the integration test — the space before the paren makes Ruby parse it as a grouped expression; worth closing up.

Note on CI

There are no workflow runs on head SHA 3384eab — only Graphite's mergeability_check reports, and the combined status is pending. So the suite hasn't actually been exercised on this branch yet.


Review requested by Mike Wilson mike.wilson@shopify.com via River.
Slack thread: https://shopify.slack.com/archives/C0BL3BCFL4A/p1786462080803919

- Guard stamp state with a mutex: results recorded from multiple threads
  (e.g. a DRb server dispatching each call on its own thread) get unique,
  gap-free per-process indexes. Documented that per-worker order
  reconstruction is only meaningful with forked workers; under
  thread-based parallelization all threads share one partition.
- Memoize the CI_QUEUE_PARALLEL_WORKER_ID env lookup per process (keyed
  on pid, so forked workers re-read it) instead of re-parsing on every
  test.
- README: moved the section under Minitest, fixed stamp-timing wording,
  documented the relationship to ci-queue's own --worker/worker_id, and
  where build/job identity is expected to come from.
- Tests: stamp now returns the result or nil so skip paths are
  assertable; added thread-safety and env-memoization tests; reset all
  stamp state ivars in setup/teardown; style fix in the integration
  assertions.

Assisted-By: devx/d62b5d38-ac27-42b3-8627-64db4c652781
@bitwise-aiden
bitwise-aiden force-pushed the parallel-worker-metadata branch from 3384eab to c6d40a8 Compare August 11, 2026 15:56
@bitwise-aiden

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — addressed in c6d40a8 (and a force-push dropping the version bump). Point by point:

1. Thread-based parallelization — Agreed, and it's actually nearer-term than with: :threads: DRb servers dispatch each incoming call on its own thread, so an embedder's reporting process could race the counter even on CRuby. Took the fix rather than the disclaimer for atomicity: stamp state is now mutex-guarded (with a thread-safety test), plus a README note that per-worker order reconstruction is only meaningful with forked workers — under threads, everything shares one (worker_id, pid) partition and the index is record order across threads. I didn't take the Thread.current option: thread identity isn't a stable partition key, there's no thread-id field in the schema to carry it, and per-thread counters would produce colliding (pid, index) pairs within a process.

2. Version bump — Fair, that's the documented release flow and the #419 precedent. Dropped from this PR; the bump is parked on a branch and will go up as a standalone PR once this merges.

3. ENV re-read per test — Fixed. Memoized keyed on Process.pid (rather than clearing in the stamp's pid-change branch) so the getter is fork-safe even when called outside stamping. Documented as "read once per process"; test added.

4. Payload self-sufficiency — Documented rather than emitted. test_data.json has never carried build/job identity for any field — attaching it is the ingestion layer's job (e.g. Core's wrapper merges buildkite_job_id), and emitting queue.config.worker_id from TestData would mean plumbing queue access into a class that deliberately has none, duplicating what the pipeline already does. README now states where job_id is expected to come from.

5. Naming — Keeping parallel_worker_*, but the relationship is now called out in the README ("a forked test process inside one queue worker; unrelated to --worker/config.worker_id"). Renaming isn't really on the table: shopify_build_test_results/3.3 is already merged with these exact field names, and Rails 8.1's ActiveSupport::TestCase.parallel_worker_id uses the same term for the same concept, so this matches the ecosystem the main embedder lives in.

Nits — All taken: section moved under Minitest (kept its own small env table — the env tables are per-feature in this README), stamp-timing wording fixed, stamp_parallel_worker_metadata now returns the result or nil so the skip paths are assertable (assert_nil in both the no-accessor and pre-stamped tests), all five stamp-state ivars reset in setup/teardown, paren spacing fixed.

CI — the missing runs were the GitHub Actions outage earlier today; the force-push should re-trigger.


AI generated (pi/claude, reviewed by @aiden before posting)

@bitwise-aiden
bitwise-aiden merged commit 04bb492 into main Aug 12, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants